package org.xx.armory.rpc.decorators;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xx.armory.bindings.BoundParamCollection;
import org.xx.armory.commons.Converter;
import org.xx.armory.commons.CryptographicException;
import org.xx.armory.rpc.Decorator;
import org.xx.armory.rpc.RpcCookie;
import org.xx.armory.rpc.RpcRequest;
import org.xx.armory.rpc.RpcResponse;
import org.xx.armory.security.AuthenticationToken;
import org.xx.armory.security.KeyStorage;
import org.xx.armory.service.Invocation;
import org.xx.armory.service.ServiceContext;

import java.math.BigDecimal;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Pattern;

import static java.util.regex.Pattern.DOTALL;
import static org.apache.commons.lang3.BooleanUtils.toBoolean;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.trimToEmpty;
import static org.apache.commons.lang3.StringUtils.trimToNull;
import static org.apache.commons.lang3.StringUtils.unwrap;
import static org.apache.commons.lang3.StringUtils.wrap;
import static org.apache.commons.text.StringEscapeUtils.escapeJava;
import static org.apache.commons.text.StringEscapeUtils.unescapeJson;
import static org.xx.armory.commons.CryptographicUtils.decryptWithBase64;
import static org.xx.armory.commons.CryptographicUtils.encryptWithBase64;
import static org.xx.armory.commons.Validators.greaterThanOrEqual;
import static org.xx.armory.commons.Validators.notBlank;
import static org.xx.armory.commons.Validators.notNull;

public abstract class AbstractAuthenticationDecorator
        extends AbstractDecorator
        implements Decorator {
    protected static final int MILLISECONDS_PER_SECOND = 1000;
    protected static final int SECONDS_PER_MINUTE = 60;
    protected static final int MINUTES_PER_ONE_YEAR = 60 * 24 * 365;
    /**
     * 保存令牌ID的Cookie名字。
     */
    protected static final String TOKEN_COOKIE_NAME = "__auth__";
    /**
     * 默认的Cookie保存路径。
     */
    protected static final String DEFAULT_PATH = "/";
    protected static final String UID_KEY_NAME = "__uid";
    protected static final String PERSIST_KEY_NAME = "__persist";
    /**
     * 默认的令牌加密密钥名称。
     */
    private static final String DEFAULT_KEY_NAME = "default";
    /**
     * 默认的滑动秒数，默认值20分钟。
     */
    private static final int DEFAULT_SLIDE = 60 * 20;
    private static final String DATE_FORMAT = "yyyy-MM-dd HH:mm:ss Z";
    private static final Pattern NUMBER_PATTERN = Pattern.compile("^(0|([1-9][0-9]*))?(\\.\\d+)?$", DOTALL);
    private static char STRING_QUOTE_CHAR = '"';
    private static char DATE_QUOTE_CHAR = '/';
    private final Logger logger = LoggerFactory.getLogger(AbstractAuthenticationDecorator.class);
    /**
     * Cookie的域名。
     */
    private String domain;
    /**
     * Cookie的保存路径。
     */
    private String path;
    /**
     * 用于加密和解密令牌ID的密钥名称。
     */
    private String keyName;
    /**
     * 滑动的秒数，每次获取令牌都会导致该令牌的有效期向后推迟指定的秒数。
     */
    private int slide;

    /**
     * 构造身份认证修饰器。
     */
    protected AbstractAuthenticationDecorator() {
        this.domain = "";
        this.path = DEFAULT_PATH;
        this.keyName = DEFAULT_KEY_NAME;
        this.slide = DEFAULT_SLIDE;
    }

    protected Date parseDate(
            String s
    )
            throws ParseException {
        return new SimpleDateFormat(DATE_FORMAT).parse(s);
    }

    protected String formatDate(
            Date d
    ) {
        return new SimpleDateFormat(DATE_FORMAT).format(d);
    }

    /**
     * 将令牌转化为映射。
     *
     * @param token
     *         待转化的令牌。
     * @return 转化后的结果，如果参数{@code token}是{@code null}则返回空映射。
     */
    protected Map<String, String> tokenToMap(
            AuthenticationToken token
    ) {
        if (token == null) {
            return Collections.emptyMap();
        } else {
            final Map<String, String> result = new LinkedHashMap<>();
            result.put(UID_KEY_NAME, token.getUserId());
            result.put(PERSIST_KEY_NAME, String.valueOf(token.isPersist()));
            token.forEach(item -> {
                final String name = item.getName();
                final Object obj = item.getObject();
                if (obj instanceof String) {
                    result.put(name, wrap(escapeJava((String) obj), STRING_QUOTE_CHAR));
                } else if (obj instanceof Date) {
                    result.put(name, wrap(formatDate((Date) obj), DATE_QUOTE_CHAR));
                } else if (obj instanceof BigDecimal) {
                    result.put(name, ((BigDecimal) obj).toPlainString());
                } else if (obj instanceof Enum) {
                    result.put(name, wrap(((Enum) obj).name(), STRING_QUOTE_CHAR));
                } else if (obj instanceof Number || obj instanceof Boolean) {
                    result.put(item.getName(), obj.toString());
                } else {
                    // 其它类型无法被序列化到字符串映射。
                    logger.warn("cannot write {}:{} {} to map", name, obj.getClass(), obj);
                }
            });
            return result;
        }
    }

    /**
     * 将映射解析为令牌。
     *
     * @param map
     *         待解析的映射。
     * @return 解析后的令牌，如果参数{@code s}是{@code null}则返回{@code null}。
     */
    protected AuthenticationToken mapToToken(
            Map<String, String> map
    ) {
        if (map == null || map.isEmpty()) {
            return null;
        }

        final String userId = map.getOrDefault(UID_KEY_NAME, "");
        final boolean persist = toBoolean(map.getOrDefault(PERSIST_KEY_NAME, ""));
        AuthenticationToken result = new AuthenticationToken(userId, persist);

        for (final Map.Entry<String, String> entry : map.entrySet()) {
            if (UID_KEY_NAME.equals(entry.getKey()) || PERSIST_KEY_NAME.equals(entry.getKey())) {
                continue;
            }

            final String key = trimToNull(entry.getKey());
            final String value = trimToNull(entry.getValue());
            if (key == null || value == null) {
                continue;
            }

            if (value.codePointAt(0) == STRING_QUOTE_CHAR) {
                result.putString(key, unescapeJson(unwrap(value, STRING_QUOTE_CHAR)));
            } else if (value.codePointAt(0) == DATE_QUOTE_CHAR) {
                try {
                    result.putDate(key, parseDate(unwrap(value, DATE_QUOTE_CHAR)));
                } catch (ParseException ex) {
                    logger.warn("cannot parse date: " + value, ex);
                }
            } else if ("true".equals(value)) {
                result.putBoolean(key, true);
            } else if ("false".equals(value)) {
                result.putBoolean(key, false);
            } else if ("null".equals(value)) {
                // 跳过空元素。
            } else if (NUMBER_PATTERN.matcher(value).find()) {
                try {
                    if (value.indexOf('.') != -1) {
                        result.putDecimal(key, new BigDecimal(value));
                    } else {
                        result.putLong(key, Long.parseLong(value));
                    }
                } catch (NumberFormatException ex) {
                    logger.warn("cannot parse number: " + value, ex);
                }
            } else {
                // 其它格式无法识别并反序列化。
                logger.warn("cannot parse {} from map", value);
            }
        }

        return result;
    }

    /**
     * 获取Cookie的domain。
     *
     * @return Cookie的domain。
     */
    public final String getDomain() {
        return this.domain;
    }

    /**
     * 设置Cookie的domain。
     *
     * @param domain
     *         Cookie的domain，自动去掉首尾空格。
     * @throws IllegalStateException
     *         如果此修饰器已初始化。
     */
    public final void setDomain(
            String domain
    ) {
        assertNotInitialized();

        this.domain = trimToEmpty(domain);
    }

    /**
     * 获取Cookie的path。
     *
     * @return Cookie的path。
     */
    public final String getPath() {
        return this.path;
    }

    /**
     * 设置Cookie的path。
     *
     * @param path
     *         Cookie的path，自动去掉首尾空格。
     * @throws IllegalStateException
     *         如果此修饰器已初始化。
     */
    public final void setPath(
            String path
    ) {
        assertNotInitialized();

        path = trimToEmpty(path);

        if (isBlank(path)) {
            this.path = DEFAULT_PATH;
        } else if (!path.startsWith("/")) {
            this.path = "/" + path;
        } else {
            this.path = path;
        }
    }

    /**
     * 获取用于加密和解密令牌ID的密钥名称。
     *
     * @return 用于加密和解密令牌ID的密钥名称。
     */
    public final String getKeyName() {
        return keyName;
    }

    /**
     * 设置用于加密和解密令牌ID的密钥名称。
     *
     * @param keyName
     *         用于加密和解密令牌ID的密钥名称，自动去掉首尾空格。
     * @throws IllegalStateException
     *         如果此修饰器已初始化。
     */
    public final void setKeyName(
            String keyName
    ) {
        assertNotInitialized();

        this.keyName = notBlank(keyName, "keyName").trim();
    }

    /**
     * 获取滑动的秒数。
     *
     * @return 滑动的秒数。
     */
    public final int getSlide() {
        return this.slide;
    }

    /**
     * 设置滑动的秒数。
     *
     * @param slide
     *         滑动的秒数。
     * @throws IllegalArgumentException
     *         如果参数{@code slide}小于{@code 0}。
     * @throws IllegalStateException
     *         如果此修饰器已初始化。
     */
    public final void setSlide(
            int slide
    ) {
        assertNotInitialized();

        this.slide = greaterThanOrEqual(slide, "slide", 0);
    }

    /**
     * 从Request.cookies中读取令牌Id并解密。
     *
     * @param request
     *         RPC请求对象。
     * @return 读取的已解密的令牌Id，如果不存在则返回空字符串。
     */
    private String loadTokenId(
            RpcRequest request
    ) {
        notNull(request, "request");

        final String encryptedTokenId = request.getCookies()
                                               .stream()
                                               .filter(cookie -> TOKEN_COOKIE_NAME.equals(cookie.getName()))
                                               .map(RpcCookie::getValue)
                                               .findAny()
                                               .orElse("");

        String tokenId;

        try {
            tokenId = decryptWithBase64(encryptedTokenId, KeyStorage.getInstance().getDESKey(this.getKeyName()));
        } catch (CryptographicException ex) {
            logger.warn("cannot decrypt token-id, use empty token-id", ex);
            tokenId = "";
        }

        if (tokenId.isEmpty()) {
            tokenId = String.valueOf(System.identityHashCode(request));
        }

        logger.trace("read token-id: {}", tokenId);

        return tokenId;
    }

    /**
     * 将加密后的tokenId写入到Response.cookies
     * <p>如果参数{@code tokenId}是{@code null}或者是空字符串则不写入Cookie</p>
     *
     * @param tokenId
     *         未加密的令牌ID。
     * @param isPersist
     *         是否需要持久保存令牌ID。
     * @param response
     *         RPC响应对象。
     */
    private void saveTokenId(
            String tokenId,
            boolean isPersist,
            RpcResponse response
    ) {
        tokenId = trimToEmpty(tokenId);

        logger.trace("write token-id: {}", tokenId);

        if (!tokenId.isEmpty()) {
            final String encryptedTokenId = encryptWithBase64(tokenId, KeyStorage.getInstance().getDESKey(this.getKeyName()));
            long maxAge;
            if (isPersist) {
                maxAge = SECONDS_PER_MINUTE * MINUTES_PER_ONE_YEAR; // 一年后过期。
            } else {
                maxAge = -1L; // 会话结束后立刻过期。
            }
            response.getCookies().add(new RpcCookie(TOKEN_COOKIE_NAME, encryptedTokenId, maxAge, "", "/"));
        }
    }

    /**
     * 根据令牌ID加载令牌。
     *
     * @param tokenId
     *         令牌ID，自动去掉首尾空格。
     * @return 令牌。
     * @throws IllegalArgumentException
     *         如果参数{@code tokenId}是{@code null}或者只包含空白字符。
     */
    protected abstract AuthenticationToken loadToken(
            String tokenId
    );

    /**
     * 保存令牌。
     *
     * @param tokenId
     *         令牌ID。
     * @param token
     *         新令牌。
     * @throws IllegalArgumentException
     *         如果参数{@code tokenId}是{@code null}或者只包含空白字符，或者参数{@code token}是{@code null}。
     */
    protected abstract void saveToken(
            String tokenId,
            AuthenticationToken token
    );

    /**
     * 使令牌变为非来宾令牌。
     *
     * @param token
     *         新的非来宾令牌。
     * @throws IllegalArgumentException
     *         如果参数{@code token}是{@code null}。
     */
    protected void signIn(
            AuthenticationToken token
    ) {
    }

    /**
     * 使令牌从非来宾令牌变为来宾令牌。
     *
     * @param token
     *         原来的非来宾令牌。
     * @throws IllegalArgumentException
     *         如果参数{@code token}是{@code null}。
     */
    protected void signOut(
            AuthenticationToken token
    ) {
    }

    /**
     * {@inheritDoc}
     */
    @Override
    protected void before(
            Invocation invocation,
            BoundParamCollection params,
            ServiceContext serviceContext
    )
            throws Exception {
        super.before(invocation, params, serviceContext);

        final String tokenId = loadTokenId(invocation.getRequest());
        final AuthenticationToken token = loadToken(tokenId);

        serviceContext.put(ServiceContext.TOKEN_ID_KEY, tokenId);
        serviceContext.setToken(token);

        logger.trace("loaded token: {}", token);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    protected Object done(
            Invocation invocation,
            BoundParamCollection params,
            ServiceContext serviceContext,
            Object result
    )
            throws Exception {
        final String tokenId = Converter.toString(serviceContext.get(ServiceContext.TOKEN_ID_KEY));

        final AuthenticationToken token = serviceContext.getToken();

        final AuthenticationToken oldToken = loadToken(tokenId);
        if (oldToken.isGuest() && !token.isGuest()) {
            signIn(token);
            logger.debug("sign-in: ", token);
        } else if (!oldToken.isGuest() && token.isGuest()) {
            signOut(oldToken);
            logger.debug("sign-out: ", oldToken);
        }

        saveToken(tokenId, token);
        saveTokenId(tokenId, token.isPersist(), invocation.getResponse());

        logger.trace("saved token: {}", token);

        return super.done(invocation, params, serviceContext, result);
    }
}
